--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 9f5a64e3657069f9751f82f7dd57976ec20e5a8a
Parents : 8e32ff7
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-06T22:03:52-05:00
feat(rnode_support): update module availability checks and improve TCP host normalization logic
Changes
5 files changed, 687 insertions(+), 47 deletions(-)
Diff
diff --git a/meshchatx/src/backend/rnode_support.py b/meshchatx/src/backend/rnode_support.py
index 800b4f60..9aeda75e 100644
--- a/meshchatx/src/backend/rnode_support.py
+++ b/meshchatx/src/backend/rnode_support.py
@@ -13,6 +13,7 @@ current build, so only the genuinely unsupported ones get disabled.
from __future__ import annotations
+import importlib.util
import logging
logger = logging.getLogger(__name__)
@@ -20,6 +21,14 @@ logger = logging.getLogger(__name__)
_TRUE_STRINGS = ("true", "yes", "1", "on")
+def _optional_module_available(module_name: str) -> bool:
+ """Return True when *module_name* can be resolved without importing it."""
+ try:
+ return importlib.util.find_spec(module_name) is not None
+ except (ImportError, ModuleNotFoundError, ValueError):
+ return False
+
+
def _is_chaquopy_android() -> bool:
try:
from meshchatx.android_push_bridge import _is_chaquopy_android as _check
@@ -31,11 +40,7 @@ def _is_chaquopy_android() -> bool:
def android_usbserial4a_available() -> bool:
"""True when usbserial4a can be imported (RNode serial/Bluetooth-classic on Android)."""
- try:
- import usbserial4a # noqa: F401
- except ImportError:
- return False
- return True
+ return _optional_module_available("usbserial4a")
def android_jnius_available() -> bool:
@@ -46,26 +51,24 @@ def android_jnius_available() -> bool:
the importable name "jnius" unless bundled explicitly (e.g. via a
compatibility shim), so this is normally unavailable.
"""
- try:
- import jnius # noqa: F401
- except ImportError:
- return False
- return True
+ return _optional_module_available("jnius")
def android_able_available() -> bool:
"""True when able can be imported (BLE GATT support for RNode ble:// on Android)."""
- import importlib.util
-
- return importlib.util.find_spec("able") is not None
+ return _optional_module_available("able")
def desktop_serial_stack_available() -> bool:
- try:
- from serial.tools import list_ports # noqa: F401
- except ImportError:
- return False
- return True
+ return _optional_module_available("serial.tools.list_ports")
+
+
+def desktop_ble_stack_available() -> bool:
+ return _optional_module_available("bleak")
+
+
+def _is_rnode_tcp_config_type(iface_type: object) -> bool:
+ return iface_type in ("RNodeInterface", "RNodeIPInterface")
def rnode_serial_supported() -> bool:
@@ -94,6 +97,19 @@ def rnode_port_is_ble(port: object) -> bool:
return str(port or "").strip().lower().startswith("ble://")
+def _tcp_host_from_port(port: object) -> str | None:
+ if not rnode_port_is_tcp(port):
+ return None
+ host_part = str(port).strip()[len("tcp://") :].strip().strip(":")
+ if not host_part or set(host_part) <= {"/"}:
+ return None
+ return host_part
+
+
+def _port_is_blank(port: object) -> bool:
+ return not str(port or "").strip()
+
+
def _rnode_iface_transport(iface: dict) -> str:
"""Classify an RNodeInterface config entry's transport.
@@ -105,7 +121,7 @@ def _rnode_iface_transport(iface: dict) -> str:
if rnode_port_is_ble(port):
return "ble"
allow_bluetooth = str(iface.get("allow_bluetooth", "")).lower() in _TRUE_STRINGS
- if not port and allow_bluetooth:
+ if _port_is_blank(port) and allow_bluetooth:
return "bluetooth_classic"
return "serial"
@@ -115,22 +131,24 @@ def rnode_transport_supported(iface: dict, *, is_android: bool | None = None) ->
RNode over TCP always works. Serial and classic-Bluetooth need
usbserial4a + jnius on Android. BLE needs able on Android. On desktop,
- every transport relies on the regular pyserial/bleak stack.
+ serial and classic-Bluetooth rely on pyserial; BLE relies on bleak.
``is_android`` lets a caller that already determined the platform pass
that result through explicitly, instead of re-detecting it here.
"""
if is_android is None:
is_android = _is_chaquopy_android()
- if not is_android:
- return desktop_serial_stack_available()
transport = _rnode_iface_transport(iface)
if transport == "tcp":
return True
+ if is_android:
+ if transport == "ble":
+ return android_able_available()
+ return android_usbserial4a_available() and android_jnius_available()
if transport == "ble":
- return android_able_available()
- return android_usbserial4a_available() and android_jnius_available()
+ return desktop_ble_stack_available()
+ return desktop_serial_stack_available()
def normalize_rnode_tcp_host_in_config(config_path: str) -> bool:
@@ -164,13 +182,13 @@ def normalize_rnode_tcp_host_in_config(config_path: str) -> bool:
for _iface_name, iface in interfaces.items():
if not isinstance(iface, dict):
continue
- if iface.get("type") != "RNodeInterface":
+ if not _is_rnode_tcp_config_type(iface.get("type")):
continue
port = iface.get("port")
if not rnode_port_is_tcp(port):
continue
- host_part = str(port).strip()[len("tcp://") :].strip().strip(":")
- if not host_part:
+ host_part = _tcp_host_from_port(port)
+ if host_part is None:
continue
if str(iface.get("tcp_host", "")).strip() != host_part:
iface["tcp_host"] = host_part
@@ -240,20 +258,47 @@ def disable_rnode_interfaces_in_config(
return modified
-def _rnode_interface_has_invalid_txpower(iface: dict) -> bool:
+def _find_invalid_rnode_txpower(
+ iface_name: str,
+ iface: dict,
+ interfaces: dict,
+) -> object | None:
from meshchatx.src.backend.interface_editor import validate_rnode_txpower
iface_type = iface.get("type", "")
if not isinstance(iface_type, str):
- return False
+ return None
if iface_type in ("RNodeInterface", "RNodeIPInterface"):
- return validate_rnode_txpower(iface.get("txpower")) is not None
- if iface_type == "RNodeMultiInterface":
- for value in iface.values():
- if isinstance(value, dict) and "txpower" in value:
- if validate_rnode_txpower(value.get("txpower")) is not None:
- return True
- return False
+ txpower = iface.get("txpower")
+ if validate_rnode_txpower(txpower) is not None:
+ return txpower
+ return None
+ if iface_type != "RNodeMultiInterface":
+ return None
+
+ for value in iface.values():
+ if isinstance(value, dict) and "txpower" in value:
+ txpower = value.get("txpower")
+ if validate_rnode_txpower(txpower) is not None:
+ return txpower
+ prefix = f"{iface_name}."
+ for sub_name, sub_iface in interfaces.items():
+ if not sub_name.startswith(prefix):
+ continue
+ if not isinstance(sub_iface, dict):
+ continue
+ txpower = sub_iface.get("txpower")
+ if validate_rnode_txpower(txpower) is not None:
+ return txpower
+ return None
+
+
+def _rnode_interface_has_invalid_txpower(
+ iface_name: str,
+ iface: dict,
+ interfaces: dict,
+) -> bool:
+ return _find_invalid_rnode_txpower(iface_name, iface, interfaces) is not None
def guard_invalid_rnode_txpower_in_config(config_path: str) -> bool:
@@ -278,18 +323,13 @@ def guard_invalid_rnode_txpower_in_config(config_path: str) -> bool:
for iface_name, iface in interfaces.items():
if not isinstance(iface, dict):
continue
- if not _rnode_interface_has_invalid_txpower(iface):
+ if not _rnode_interface_has_invalid_txpower(iface_name, iface, interfaces):
continue
- if str(iface.get("interface_enabled", "")).lower() not in _TRUE_STRINGS:
+ if not _is_enabled(iface):
continue
iface["interface_enabled"] = "false"
modified = True
- txpower = iface.get("txpower")
- if txpower is None:
- for value in iface.values():
- if isinstance(value, dict) and "txpower" in value:
- txpower = value.get("txpower")
- break
+ txpower = _find_invalid_rnode_txpower(iface_name, iface, interfaces)
detail = validate_rnode_txpower(txpower) or "invalid TX power"
logger.warning(
'Disabled RNode interface "%s" before startup: %s',
diff --git a/meshchatx/src/frontend/js/settings/settingsTabs.js b/meshchatx/src/frontend/js/settings/settingsTabs.js
index a99d097b..4e4c2d91 100644
--- a/meshchatx/src/frontend/js/settings/settingsTabs.js
+++ b/meshchatx/src/frontend/js/settings/settingsTabs.js
@@ -44,13 +44,28 @@ export const SETTINGS_TABS = [
export const DEFAULT_SETTINGS_TAB = "general";
+/** @type {readonly string[]} */
+export const ALL_SETTINGS_SECTIONS = Object.freeze(SETTINGS_TABS.flatMap((tab) => tab.sections));
+
+/**
+ * @param {string | undefined | null} tabId
+ * @returns {SettingsTab | null}
+ */
+export function getSettingsTab(tabId) {
+ if (!tabId) {
+ return null;
+ }
+ return SETTINGS_TABS.find((tab) => tab.id === tabId) ?? null;
+}
+
/**
* @param {string | undefined | null} tabId
* @returns {string}
*/
export function normalizeSettingsTabId(tabId) {
- if (tabId && SETTINGS_TABS.some((tab) => tab.id === tabId)) {
- return tabId;
+ const normalized = typeof tabId === "string" ? tabId.trim() : "";
+ if (normalized && SETTINGS_TABS.some((tab) => tab.id === normalized)) {
+ return normalized;
}
return DEFAULT_SETTINGS_TAB;
}
@@ -63,3 +78,13 @@ export function settingsTabForSection(sectionKey) {
const tab = SETTINGS_TABS.find((entry) => entry.sections.includes(sectionKey));
return tab ? tab.id : null;
}
+
+/**
+ * @param {string} sectionKey
+ * @param {string} tabId
+ * @returns {boolean}
+ */
+export function settingsSectionBelongsToTab(sectionKey, tabId) {
+ const tab = getSettingsTab(tabId);
+ return Boolean(tab && tab.sections.includes(sectionKey));
+}
diff --git a/tests/backend/test_rnode_support.py b/tests/backend/test_rnode_support.py
index 4b1b9d6e..8f74773c 100644
--- a/tests/backend/test_rnode_support.py
+++ b/tests/backend/test_rnode_support.py
@@ -1,5 +1,8 @@
# SPDX-License-Identifier: 0BSD
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
from meshchatx.src.backend import rnode_support
@@ -59,6 +62,87 @@ def test_normalize_rnode_tcp_host_is_idempotent(tmp_path):
assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is False
+@pytest.mark.parametrize(
+ ("port", "expected"),
+ [
+ ("tcp://192.0.2.1:4242", "192.0.2.1:4242"),
+ ("tcp://mesh.example", "mesh.example"),
+ ("tcp://mesh.example:", "mesh.example"),
+ ("tcp://", None),
+ ("tcp:///", None),
+ ("/dev/ttyUSB0", None),
+ ],
+)
+def test_tcp_host_from_port(port, expected):
+ assert rnode_support._tcp_host_from_port(port) == expected
+
+
+def test_normalize_rnode_tcp_host_handles_port_variants(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+[[RNode TCP]]
+type = RNodeInterface
+interface_enabled = True
+port = TCP://mesh.example:4242
+""",
+ encoding="utf-8",
+ )
+
+ assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is True
+ assert "tcp_host = mesh.example:4242" in config_path.read_text(encoding="utf-8")
+
+
+@pytest.mark.parametrize(
+ "port",
+ ["tcp://", " tcp:// ", "tcp:///"],
+)
+def test_normalize_rnode_tcp_host_skips_empty_host(tmp_path, port):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ f"""[interfaces]
+[[RNode TCP]]
+type = RNodeInterface
+interface_enabled = True
+port = {port}
+""",
+ encoding="utf-8",
+ )
+
+ assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is False
+ assert "tcp_host" not in config_path.read_text(encoding="utf-8")
+
+
+def test_normalize_rnode_tcp_host_updates_mismatched_tcp_host(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+[[RNode TCP]]
+type = RNodeInterface
+interface_enabled = True
+port = tcp://192.0.2.1:4242
+tcp_host = stale.example:1
+""",
+ encoding="utf-8",
+ )
+
+ assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is True
+ assert "tcp_host = 192.0.2.1:4242" in config_path.read_text(encoding="utf-8")
+
+
+def test_normalize_rnode_tcp_host_missing_file_returns_false(tmp_path):
+ assert (
+ rnode_support.normalize_rnode_tcp_host_in_config(str(tmp_path / "missing"))
+ is False
+ )
+
+
+def test_normalize_rnode_tcp_host_invalid_config_returns_false(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text("not valid configobj syntax [[[", encoding="utf-8")
+ assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is False
+
+
def test_guard_disables_rnode_when_usbserial4a_missing(tmp_path, monkeypatch):
config_path = tmp_path / "config"
config_path.write_text(
@@ -174,6 +258,8 @@ def test_guard_keeps_ble_rnode_when_able_available(tmp_path, monkeypatch):
)
monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
monkeypatch.setattr(rnode_support, "android_able_available", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: True)
assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is False
assert "interface_enabled = True" in config_path.read_text(encoding="utf-8")
@@ -215,6 +301,43 @@ def test_guard_skips_rnode_multi_interface_off_android(tmp_path, monkeypatch):
assert "interface_enabled = True" in config_path.read_text(encoding="utf-8")
+def test_guard_skips_already_disabled_interfaces(tmp_path, monkeypatch):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+[[RNode Serial]]
+type = RNodeInterface
+interface_enabled = false
+port = /dev/ttyUSB0
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: False)
+
+ assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is False
+
+
+@pytest.mark.parametrize("enabled_key", ["interface_enabled", "enabled"])
+def test_guard_honors_enabled_key_variants(tmp_path, monkeypatch, enabled_key):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ f"""[interfaces]
+[[RNode Serial]]
+type = RNodeInterface
+{enabled_key} = yes
+port = /dev/ttyUSB0
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: False)
+ monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: False)
+
+ assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is True
+ assert "interface_enabled = false" in config_path.read_text(encoding="utf-8")
+
+
def test_rnode_serial_supported_on_desktop(monkeypatch):
monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: False)
assert rnode_support.rnode_serial_supported() is True
@@ -247,6 +370,34 @@ def test_rnode_transport_supported_tcp_always_true_on_android(monkeypatch):
)
+def test_rnode_transport_supported_tcp_always_true_on_desktop_without_pyserial(
+ monkeypatch,
+):
+ monkeypatch.setattr(rnode_support, "desktop_serial_stack_available", lambda: False)
+
+ assert (
+ rnode_support.rnode_transport_supported(
+ {"port": "tcp://example.org:4242"},
+ is_android=False,
+ )
+ is True
+ )
+
+
+def test_rnode_transport_supported_serial_false_on_desktop_without_pyserial(
+ monkeypatch,
+):
+ monkeypatch.setattr(rnode_support, "desktop_serial_stack_available", lambda: False)
+
+ assert (
+ rnode_support.rnode_transport_supported(
+ {"port": "/dev/ttyUSB0"},
+ is_android=False,
+ )
+ is False
+ )
+
+
def test_rnode_transport_supported_bluetooth_classic_needs_usbserial_and_jnius(
monkeypatch,
):
@@ -261,6 +412,85 @@ def test_rnode_transport_supported_bluetooth_classic_needs_usbserial_and_jnius(
assert rnode_support.rnode_transport_supported(iface) is False
+def test_rnode_transport_supported_whitespace_port_with_bluetooth_classic(
+ monkeypatch,
+):
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: True)
+
+ iface = {"port": " ", "allow_bluetooth": "yes"}
+ assert rnode_support.rnode_transport_supported(iface) is True
+
+
+@pytest.mark.parametrize(
+ ("port", "is_tcp", "is_ble"),
+ [
+ ("tcp://host:1", True, False),
+ ("TCP://HOST:1", True, False),
+ (" tcp://host ", True, False),
+ ("ble://aa:bb:cc:dd:ee:ff", False, True),
+ ("BLE://AA:BB", False, True),
+ ("/dev/ttyUSB0", False, False),
+ (None, False, False),
+ ("", False, False),
+ (" ", False, False),
+ (123, False, False),
+ ("serial://not-tcp", False, False),
+ ],
+)
+def test_rnode_port_prefix_detection(port, is_tcp, is_ble):
+ assert rnode_support.rnode_port_is_tcp(port) is is_tcp
+ assert rnode_support.rnode_port_is_ble(port) is is_ble
+
+
+@pytest.mark.parametrize(
+ ("iface", "expected"),
+ [
+ ({"port": "tcp://host:1"}, "tcp"),
+ ({"port": "ble://aa:bb"}, "ble"),
+ ({"port": "", "allow_bluetooth": "true"}, "bluetooth_classic"),
+ ({"port": " ", "allow_bluetooth": "on"}, "bluetooth_classic"),
+ ({"port": "/dev/ttyUSB0"}, "serial"),
+ ({"port": "/dev/ttyUSB0", "allow_bluetooth": "true"}, "serial"),
+ ],
+)
+def test_rnode_iface_transport_classification(iface, expected):
+ assert rnode_support._rnode_iface_transport(iface) == expected
+
+
+def test_disable_rnode_interfaces_on_desktop_disables_serial_without_pyserial(
+ tmp_path,
+ monkeypatch,
+):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+[[RNode Serial]]
+type = RNodeInterface
+interface_enabled = True
+port = /dev/ttyUSB0
+[[RNode TCP]]
+type = RNodeInterface
+interface_enabled = True
+port = tcp://192.0.2.1:4242
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(rnode_support, "desktop_serial_stack_available", lambda: False)
+
+ assert (
+ rnode_support.disable_rnode_interfaces_in_config(
+ str(config_path),
+ is_android=False,
+ )
+ is True
+ )
+ text = config_path.read_text(encoding="utf-8").lower()
+ assert "interface_enabled = false" in text
+ assert text.count("interface_enabled = false") == 1
+
+
def test_guard_disables_rnode_with_invalid_txpower(tmp_path):
config_path = tmp_path / "config"
config_path.write_text(
@@ -299,3 +529,203 @@ txpower = 7
rnode_support.guard_invalid_rnode_txpower_in_config(str(config_path)) is False
)
assert "interface_enabled = True" in config_path.read_text(encoding="utf-8")
+
+
+def test_guard_disables_rnode_multi_with_invalid_nested_txpower(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+[[RNode Multi]]
+type = RNodeMultiInterface
+interface_enabled = True
+
+[[RNode Multi.r1]]
+txpower = -9
+""",
+ encoding="utf-8",
+ )
+
+ assert rnode_support.guard_invalid_rnode_txpower_in_config(str(config_path)) is True
+ assert (
+ "interface_enabled = false" in config_path.read_text(encoding="utf-8").lower()
+ )
+
+
+def test_guard_invalid_txpower_honors_enabled_key(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+[[Radio]]
+type = RNodeInterface
+enabled = yes
+txpower = -9
+""",
+ encoding="utf-8",
+ )
+
+ assert rnode_support.guard_invalid_rnode_txpower_in_config(str(config_path)) is True
+ assert (
+ "interface_enabled = false" in config_path.read_text(encoding="utf-8").lower()
+ )
+
+
+def test_guard_invalid_txpower_skips_already_disabled(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+[[Radio]]
+type = RNodeInterface
+interface_enabled = false
+txpower = -9
+""",
+ encoding="utf-8",
+ )
+
+ assert (
+ rnode_support.guard_invalid_rnode_txpower_in_config(str(config_path)) is False
+ )
+
+
+def test_optional_module_available_uses_find_spec(monkeypatch):
+ monkeypatch.setattr(
+ rnode_support.importlib.util,
+ "find_spec",
+ lambda name: object() if name == "present" else None,
+ )
+ assert rnode_support._optional_module_available("present") is True
+ assert rnode_support._optional_module_available("missing") is False
+
+
+def test_optional_module_available_handles_find_spec_errors(monkeypatch):
+ def _boom(_name):
+ raise ValueError("broken meta path")
+
+ monkeypatch.setattr(rnode_support.importlib.util, "find_spec", _boom)
+ assert rnode_support._optional_module_available("anything") is False
+
+
+@settings(max_examples=200, deadline=None)
+@given(
+ prefix=st.sampled_from(["tcp://", "TCP://", " tcp://"]),
+ host=st.text(
+ alphabet=st.characters(blacklist_categories=("Cs",), blacklist_characters="/"),
+ min_size=1,
+ max_size=32,
+ ),
+)
+def test_rnode_port_is_tcp_never_false_positive_on_tcp_prefix(prefix, host):
+ assert rnode_support.rnode_port_is_tcp(f"{prefix}{host}") is True
+
+
+@settings(max_examples=200, deadline=None)
+@given(
+ noise=st.text(min_size=0, max_size=16),
+)
+def test_rnode_port_is_tcp_rejects_non_tcp_prefixes(noise):
+ candidate = noise.strip()
+ if candidate.lower().startswith("tcp://"):
+ return
+ assert rnode_support.rnode_port_is_tcp(candidate) is False
+
+
+def test_normalize_rnode_tcp_host_backfills_rnode_ip_interface(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+[[RNode IP]]
+type = RNodeIPInterface
+interface_enabled = True
+port = tcp://192.0.2.5:4242
+""",
+ encoding="utf-8",
+ )
+
+ assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is True
+ assert "tcp_host = 192.0.2.5:4242" in config_path.read_text(encoding="utf-8")
+
+
+def test_rnode_transport_supported_ble_on_desktop_needs_bleak(monkeypatch):
+ monkeypatch.setattr(rnode_support, "desktop_ble_stack_available", lambda: False)
+ monkeypatch.setattr(rnode_support, "desktop_serial_stack_available", lambda: True)
+
+ assert (
+ rnode_support.rnode_transport_supported(
+ {"port": "ble://aa:bb:cc:dd:ee:ff"},
+ is_android=False,
+ )
+ is False
+ )
+
+
+def test_rnode_transport_supported_ble_on_desktop_with_bleak(monkeypatch):
+ monkeypatch.setattr(rnode_support, "desktop_ble_stack_available", lambda: True)
+
+ assert (
+ rnode_support.rnode_transport_supported(
+ {"port": "ble://aa:bb:cc:dd:ee:ff"},
+ is_android=False,
+ )
+ is True
+ )
+
+
+def test_disable_rnode_skips_sub_interface_siblings(tmp_path, monkeypatch):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+[[RNode Multi]]
+type = RNodeMultiInterface
+interface_enabled = True
+
+[[RNode Multi.r1]]
+txpower = 7
+frequency = 868000000
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+
+ assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is True
+ text = config_path.read_text(encoding="utf-8")
+ assert "interface_enabled = false" in text.lower()
+ assert "txpower = 7" in text
+
+
+def test_startup_repair_sequence_applies_all_guards(tmp_path, monkeypatch):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[interfaces]
+[[RNode TCP]]
+type = RNodeInterface
+interface_enabled = True
+port = tcp://192.0.2.1:4242
+txpower = -9
+
+[[RNode Serial]]
+type = RNodeInterface
+interface_enabled = True
+port = /dev/ttyUSB0
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
+ monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: False)
+ monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: False)
+
+ assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is True
+ assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is True
+ assert rnode_support.guard_invalid_rnode_txpower_in_config(str(config_path)) is True
+
+ text = config_path.read_text(encoding="utf-8")
+ assert "tcp_host = 192.0.2.1:4242" in text
+ assert text.lower().count("interface_enabled = false") == 2
+
+
+@settings(max_examples=100, deadline=None)
+@given(
+ host=st.from_regex(r"[A-Za-z0-9][A-Za-z0-9.-]{0,31}", fullmatch=True),
+ port=st.integers(min_value=1, max_value=65535),
+)
+def test_tcp_host_from_port_round_trips_host_port(host, port):
+ value = f"tcp://{host}:{port}"
+ assert rnode_support._tcp_host_from_port(value) == f"{host}:{port}"
diff --git a/tests/frontend/SettingsNav.test.js b/tests/frontend/SettingsNav.test.js
new file mode 100644
index 00000000..a2d8888a
--- /dev/null
+++ b/tests/frontend/SettingsNav.test.js
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: 0BSD
+
+import { mount } from "@vue/test-utils";
+import { describe, expect, it } from "vitest";
+import SettingsNav from "../../meshchatx/src/frontend/components/settings/SettingsNav.vue";
+import { SETTINGS_TABS } from "../../meshchatx/src/frontend/js/settings/settingsTabs.js";
+
+describe("SettingsNav", () => {
+ function mountNav(activeTab = "general") {
+ return mount(SettingsNav, {
+ props: { activeTab },
+ global: {
+ mocks: {
+ $t: (key) => key,
+ },
+ },
+ });
+ }
+
+ it("renders every settings tab", () => {
+ const wrapper = mountNav();
+ const tabs = wrapper.findAll(".settings-nav__tab");
+ expect(tabs).toHaveLength(SETTINGS_TABS.length);
+ for (const tab of SETTINGS_TABS) {
+ expect(wrapper.text()).toContain(tab.labelKey);
+ }
+ });
+
+ it("marks the active tab with aria-current", () => {
+ const wrapper = mountNav("privacy");
+ const active = wrapper.find('[aria-current="page"]');
+ expect(active.exists()).toBe(true);
+ expect(active.text()).toContain("settings.tabs.privacy");
+ });
+
+ it("emits select when a tab is clicked", async () => {
+ const wrapper = mountNav("general");
+ const buttons = wrapper.findAll(".settings-nav__tab");
+ const networkButton = buttons.find((btn) => btn.text().includes("settings.tabs.network"));
+ expect(networkButton).toBeDefined();
+ await networkButton.trigger("click");
+ expect(wrapper.emitted("select")).toEqual([["network"]]);
+ });
+
+ it("exposes an accessible navigation landmark", () => {
+ const wrapper = mountNav();
+ const nav = wrapper.find("nav.settings-nav");
+ expect(nav.attributes("aria-label")).toBe("Settings sections");
+ });
+});
diff --git a/tests/frontend/settingsTabs.test.js b/tests/frontend/settingsTabs.test.js
index ce3be093..37730168 100644
--- a/tests/frontend/settingsTabs.test.js
+++ b/tests/frontend/settingsTabs.test.js
@@ -1,28 +1,72 @@
// SPDX-License-Identifier: 0BSD
import { describe, expect, it } from "vitest";
+import en from "../../meshchatx/src/frontend/locales/en.json";
import {
+ ALL_SETTINGS_SECTIONS,
DEFAULT_SETTINGS_TAB,
+ getSettingsTab,
normalizeSettingsTabId,
SETTINGS_TABS,
+ settingsSectionBelongsToTab,
settingsTabForSection,
} from "../../meshchatx/src/frontend/js/settings/settingsTabs.js";
+const KNOWN_SECTIONS_FROM_SETTINGS_PAGE = [
+ "strangerProtection",
+ "banishment",
+ "stickers",
+ "gifs",
+ "maintenance",
+ "telephony",
+ "desktop",
+ "android",
+ "archiver",
+ "nomadRenderer",
+ "crawler",
+ "appearance",
+ "visualiser",
+ "location",
+ "language",
+ "networkSecurity",
+ "transport",
+ "interfaces",
+ "blocked",
+ "privacyData",
+ "auth",
+ "webExposure",
+ "infrastructure",
+ "csp",
+ "messages",
+ "propagation",
+ "shortcuts",
+];
+
describe("settingsTabs", () => {
it("defaults to general tab", () => {
expect(DEFAULT_SETTINGS_TAB).toBe("general");
expect(normalizeSettingsTabId(undefined)).toBe("general");
+ expect(normalizeSettingsTabId(null)).toBe("general");
+ expect(normalizeSettingsTabId("")).toBe("general");
+ expect(normalizeSettingsTabId(" ")).toBe("general");
expect(normalizeSettingsTabId("invalid")).toBe("general");
});
- it("normalizes valid tab ids", () => {
+ it("normalizes valid tab ids and trims whitespace", () => {
expect(normalizeSettingsTabId("privacy")).toBe("privacy");
+ expect(normalizeSettingsTabId(" privacy ")).toBe("privacy");
+ });
+
+ it("rejects case-mismatched tab ids", () => {
+ expect(normalizeSettingsTabId("General")).toBe("general");
+ expect(normalizeSettingsTabId("PRIVACY")).toBe("general");
});
it("maps sections to tabs", () => {
expect(settingsTabForSection("appearance")).toBe("general");
expect(settingsTabForSection("messages")).toBe("messages");
expect(settingsTabForSection("archiver")).toBe("nomad");
+ expect(settingsTabForSection("unknown-section")).toBeNull();
});
it("includes every section exactly once", () => {
@@ -32,4 +76,55 @@ describe("settingsTabs", () => {
expect(seen).toContain("networkSecurity");
expect(seen).toContain("maintenance");
});
+
+ it("exports ALL_SETTINGS_SECTIONS matching flattened tab sections", () => {
+ expect(ALL_SETTINGS_SECTIONS).toEqual(SETTINGS_TABS.flatMap((tab) => tab.sections));
+ expect(Object.isFrozen(ALL_SETTINGS_SECTIONS)).toBe(true);
+ });
+
+ it("uses unique tab ids and non-empty labels", () => {
+ const ids = SETTINGS_TABS.map((tab) => tab.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ for (const tab of SETTINGS_TABS) {
+ expect(tab.labelKey.length).toBeGreaterThan(0);
+ expect(tab.descriptionKey.length).toBeGreaterThan(0);
+ expect(tab.sections.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("covers every SettingsPage section key", () => {
+ for (const sectionKey of KNOWN_SECTIONS_FROM_SETTINGS_PAGE) {
+ expect(ALL_SETTINGS_SECTIONS).toContain(sectionKey);
+ expect(settingsTabForSection(sectionKey)).not.toBeNull();
+ }
+ });
+
+ it("round-trips section to tab membership", () => {
+ for (const sectionKey of ALL_SETTINGS_SECTIONS) {
+ const tabId = settingsTabForSection(sectionKey);
+ expect(tabId).not.toBeNull();
+ expect(settingsSectionBelongsToTab(sectionKey, tabId)).toBe(true);
+ for (const otherTab of SETTINGS_TABS) {
+ if (otherTab.id === tabId) {
+ continue;
+ }
+ expect(settingsSectionBelongsToTab(sectionKey, otherTab.id)).toBe(false);
+ }
+ }
+ });
+
+ it("getSettingsTab returns tab metadata or null", () => {
+ expect(getSettingsTab("network")?.id).toBe("network");
+ expect(getSettingsTab("missing")).toBeNull();
+ expect(getSettingsTab("")).toBeNull();
+ });
+
+ it("uses i18n keys that exist in en.json", () => {
+ for (const tab of SETTINGS_TABS) {
+ const labelTail = tab.labelKey.replace("settings.tabs.", "");
+ const descTail = tab.descriptionKey.replace("settings.tabs.", "");
+ expect(en.settings.tabs[labelTail]).toBeTruthy();
+ expect(en.settings.tabs[descTail]).toBeTruthy();
+ }
+ });
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────